{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Note: The BlazingText portion of this notebook does not work the default `t2.medium`.  It only works with `t2.large` and bigger.  The BlazingText model is 750MB and requires a larger instance."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Building a Recommender System with Amazon SageMaker BlazingText"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- What happens when a new movie is added?\n",
    "  - No feature to set to \"1\" in the dataset\n",
    "  - No previous ratings to find similar items\n",
    "  - Cold start problem is hard with factorization machines\n",
    "- Word2vec\n",
    "  - Word embeddings for natural language processing (similar words get similar vectors)\n",
    "  - Use concatenated product titles as words, customer review history as sentences\n",
    "  - SageMaker BlazingText is an extremely fast implementation that can work with subwords"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Set up the environment\n",
    "\n",
    "Let's start by setting up the environment:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Install Dependencies\n",
    "_Note:  Ignore Warnings and Errors Below_"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We need `scikit-learn==0.22.1` to work around the TSNE `asmatrix` issue"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip uninstall -y scikit-learn==0.22.1"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip install scikit-learn==0.22.1 --upgrade --ignore-installed --no-cache --user"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from IPython.display import display_html\n",
    "display_html(\"<script>Jupyter.notebook.kernel.restart()</script>\", raw=True)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip3 list"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import sagemaker\n",
    "\n",
    "sess = sagemaker.Session()\n",
    "bucket = sess.default_bucket()\n",
    "base = 'DEMO-loft-recommender'\n",
    "prefix = 'sagemaker/' + base\n",
    "\n",
    "role = sagemaker.get_execution_role()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import sagemaker\n",
    "import os\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "import boto3\n",
    "import json\n",
    "import io\n",
    "import matplotlib.pyplot as plt\n",
    "import sagemaker.amazon.common as smac\n",
    "from sagemaker.predictor import json_deserializer\n",
    "from scipy.sparse import csr_matrix"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Data\n",
    "\n",
    "[Amazon Reviews AWS Public Dataset](https://s3.amazonaws.com/amazon-reviews-pds/readme.html)\n",
    "- 1 to 5 star ratings\n",
    "- 2M+ Amazon customers\n",
    "- 160K+ digital videos "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!mkdir -p /tmp/recsys/\n",
    "!aws s3 cp s3://amazon-reviews-pds/tsv/amazon_reviews_us_Digital_Video_Download_v1_00.tsv.gz /tmp/recsys/"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df = pd.read_csv('/tmp/recsys/amazon_reviews_us_Digital_Video_Download_v1_00.tsv.gz', delimiter='\\t',error_bad_lines=False)\n",
    "df.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Dataset columns:\n",
    "\n",
    "- `marketplace`: 2-letter country code (in this case all \"US\").\n",
    "- `customer_id`: Random identifier that can be used to aggregate reviews written by a single author.\n",
    "- `review_id`: A unique ID for the review.\n",
    "- `product_id`: The Amazon Standard Identification Number (ASIN).  `http://www.amazon.com/dp/<ASIN>` links to the product's detail page.\n",
    "- `product_parent`: The parent of that ASIN.  Multiple ASINs (color or format variations of the same product) can roll up into a single parent.\n",
    "- `product_title`: Title description of the product.\n",
    "- `product_category`: Broad product category that can be used to group reviews (in this case digital videos).\n",
    "- `star_rating`: The review's rating (1 to 5 stars).\n",
    "- `helpful_votes`: Number of helpful votes for the review.\n",
    "- `total_votes`: Number of total votes the review received.\n",
    "- `vine`: Was the review written as part of the [Vine](https://www.amazon.com/gp/vine/help) program?\n",
    "- `verified_purchase`: Was the review from a verified purchase?\n",
    "- `review_headline`: The title of the review itself.\n",
    "- `review_body`: The text of the review.\n",
    "- `review_date`: The date the review was written.\n",
    "\n",
    "Drop some fields that won't be used"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df = df[['customer_id', 'product_id', 'product_title', 'star_rating', 'review_date']]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Most users don't rate most movies - Check our long tail"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customers = df['customer_id'].value_counts()\n",
    "products = df['product_id'].value_counts()\n",
    "\n",
    "quantiles = [0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.96, 0.97, 0.98, 0.99, 1]\n",
    "print('customers\\n', customers.quantile(quantiles))\n",
    "print('products\\n', products.quantile(quantiles))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Filter out customers who haven't rated many movies"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customers = customers[customers >= 5]\n",
    "products = products[products >= 10]\n",
    "\n",
    "reduced_df = df.merge(pd.DataFrame({'customer_id': customers.index})).merge(pd.DataFrame({'product_id': products.index}))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Create a sequential index for customers and movies"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customers = reduced_df['customer_id'].value_counts()\n",
    "products = reduced_df['product_id'].value_counts()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customer_index = pd.DataFrame({'customer_id': customers.index, 'user': np.arange(customers.shape[0])})\n",
    "product_index = pd.DataFrame({'product_id': products.index, \n",
    "                              'item': np.arange(products.shape[0]) + customer_index.shape[0]})\n",
    "\n",
    "reduced_df = reduced_df.merge(customer_index).merge(product_index)\n",
    "reduced_df.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Count days since first review (included as a feature to capture trend)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "reduced_df['review_date'] = pd.to_datetime(reduced_df['review_date'])\n",
    "customer_first_date = reduced_df.groupby('customer_id')['review_date'].min().reset_index()\n",
    "customer_first_date.columns = ['customer_id', 'first_review_date']"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "reduced_df = reduced_df.merge(customer_first_date)\n",
    "reduced_df['days_since_first'] = (reduced_df['review_date'] - reduced_df['first_review_date']).dt.days\n",
    "reduced_df['days_since_first'] = reduced_df['days_since_first'].fillna(0)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Data\n",
    "\n",
    "Concatenate product titles to treat each one as a single word"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "reduced_df['product_title'] = reduced_df['product_title'].apply(lambda x: x.lower().replace(' ', '-'))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Write customer purchase histories"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "first = True\n",
    "with open('customer_purchases.txt', 'w') as f:\n",
    "    for customer, data in reduced_df.sort_values(['customer_id', 'review_date']).groupby('customer_id'):\n",
    "        if first:\n",
    "            first = False\n",
    "        else:\n",
    "            f.write('\\n')\n",
    "        f.write(' '.join(data['product_title'].tolist()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Write to S3 so SageMaker training can use it"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "inputs = sess.upload_data('customer_purchases.txt', bucket, '{}/word2vec/train'.format(prefix))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Train\n",
    "\n",
    "Create a SageMaker estimator:\n",
    "- Specify training job arguments\n",
    "- Set hyperparameters\n",
    "  - Remove titles that occur less than 5 times\n",
    "  - Embed in a 100-dimensional subspace\n",
    "  - Use subwords to capture similarity in titles"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "bt = sagemaker.estimator.Estimator(\n",
    "    sagemaker.amazon.amazon_estimator.get_image_uri(boto3.Session().region_name, 'blazingtext', 'latest'),\n",
    "    role, \n",
    "    train_instance_count=1, \n",
    "    train_instance_type='ml.c5.4xlarge',\n",
    "    train_volume_size = 5,\n",
    "    output_path='s3://{}/{}/output'.format(bucket, prefix),\n",
    "    sagemaker_session=sess)\n",
    "\n",
    "bt.set_hyperparameters(mode=\"skipgram\",\n",
    "    epochs=10,\n",
    "    min_count=5,\n",
    "    sampling_threshold=0.0001,\n",
    "    learning_rate=0.05,\n",
    "    window_size=5,\n",
    "    vector_dim=100,\n",
    "    negative_samples=5,\n",
    "    min_char=5,\n",
    "    max_char=10,\n",
    "    evaluation=False,\n",
    "    subwords=True)\n",
    "\n",
    "bt.fit({'train': sagemaker.s3_input(inputs, distribution='FullyReplicated', content_type='text/plain')})"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Model\n",
    "\n",
    "- Bring in and extract the model from S3\n",
    "- Take a look at the embeddings"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!aws s3 cp $bt.model_data ./"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!tar -xvzf model.tar.gz"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vectors = pd.read_csv('vectors.txt', delimiter=' ', skiprows=2, header=None)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Do the embeddings appear to have meaning"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vectors.sort_values(1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vectors.sort_values(2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Yes, but there's 100.  Let's reduce this further with t-SNE and map the top 100 titles."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "product_titles = vectors[0]\n",
    "vectors = vectors.drop([0, 101], axis=1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.manifold import TSNE\n",
    "\n",
    "tsne = TSNE(perplexity=40, n_components=2, init='pca', n_iter=10000)\n",
    "embeddings = tsne.fit_transform(vectors.values[:100, ])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from matplotlib import pylab\n",
    "%matplotlib inline\n",
    "\n",
    "def plot(embeddings, labels):\n",
    "    pylab.figure(figsize=(20,20))\n",
    "    for i, label in enumerate(labels):\n",
    "        x, y = embeddings[i,:]\n",
    "        pylab.scatter(x, y)\n",
    "        pylab.annotate(label, xy=(x, y), xytext=(5, 2), textcoords='offset points',\n",
    "                       ha='right', va='bottom')\n",
    "    pylab.show()\n",
    "\n",
    "plot(embeddings, product_titles[:100])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Host\n",
    "\n",
    "Deploy our model to a real-time endpoint."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "bt_endpoint = bt.deploy(initial_instance_count = 1,instance_type = 'ml.m4.xlarge')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Try generating predictions for a set of titles (some of which are real, some of which are made up)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "words = [\"sherlock-season-1\", \n",
    "         \"sherlock-season-2\",\n",
    "         \"sherlock-season-5\",\n",
    "         'arbitrary-sherlock-holmes-string',\n",
    "         'the-imitation-game',\n",
    "         \"abcdefghijklmn\",\n",
    "         \"keeping-up-with-the-kardashians-season-1\"]\n",
    "\n",
    "payload = {\"instances\" : words}\n",
    "\n",
    "response = bt_endpoint.predict(json.dumps(payload))\n",
    "\n",
    "vecs_df = pd.DataFrame(json.loads(response))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Calculate correlation and distance."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vecs_df = pd.DataFrame(vecs_df['vector'].values.tolist(), index=vecs_df['word'])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vecs_df = vecs_df.transpose()\n",
    "vecs_df.corr()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for column in vecs_df.columns:\n",
    "    print(column + ':', np.sum((vecs_df[column] - vecs_df['sherlock-season-1']) ** 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Relative to 'sherlock-season-1':\n",
    "- 'sherlock-season-5' is made up, but relates well with 'sherlock-season-1' and 'sherlock-season-2'\n",
    "- 'arbitrary-sherlock-holmes-string' is also made up and relates less well but still fairly strong\n",
    "- 'the-imitation-game' is another popular Prime video title starring Benedict Cumberbatch and has a moderate relationship, but worse than the arbitrary Sherlock title\n",
    "- 'abcdefghijklmn' is made up and relates even worse\n",
    "- 'keeping-up-with-the-kardashians-season-1' somehow manages to relate even worse\n",
    "\n",
    "Clean-up the endpoint"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "bt_endpoint.delete_endpoint()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "\n",
    "# Beyond the Basics\n",
    "- Add more features to improve the recommendations\n",
    "- Compare to other methods\n",
    "- Ensemble two model recommendations"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "conda_python3",
   "language": "python",
   "name": "conda_python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.5"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
